/**
 * Copyright (c) 2011-2013, James Zhan 詹波 (jfinal@126.com).
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.jfinal.plugin.activerecord;

import com.google.common.collect.Lists;
import com.jfinal.plugin.activerecord.cache.ICache;
import com.jfinal.sog.kit.StringPool;

import java.io.Serializable;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import static com.jfinal.plugin.activerecord.DbKit.NULL_PARA_ARRAY;

/**
 * Model.
 * <p/>
 * A clever person solves a problem.
 * A wise person avoids it.
 * A stupid person makes it.
 */
@SuppressWarnings({"rawtypes", "unchecked", "UnusedDeclaration"})
public abstract class Model<M extends Model> implements Serializable {

    private static final long serialVersionUID = -4890964905769110400L;

    /**
     * Attributes of this model
     */
    private Map<String, Object> attrs = DbKit.containerFactory.getAttrsMap();    // new HashMap<String, Object>();

    /**
     * Flag of column has been modified. update need this flag
     */
    private Set<String> modifyFlag;

    private static final TableInfoMapping tableInfoMapping = TableInfoMapping.me();

    private Set<String> getModifyFlag() {
        if (modifyFlag == null)
            modifyFlag = DbKit.containerFactory.getModifyFlagSet();    // new HashSet<String>();
        return modifyFlag;
    }

    /**
     * 增加获取主键的方法
     * 因为太多地方需要了.
     *
     * @param <T> 泛型参数
     * @return 主键值
     */
    protected <T> T pk() {
        final TableInfo tableInfo = tableInfoMapping.getTableInfo(getClass());
        return get(tableInfo.getPrimaryKey());
    }

    /**
     * Set attribute to model.
     *
     * @param attr  the attribute name of the model
     * @param value the value of the attribute
     * @return this model
     * @throws ActiveRecordException if the attribute is not exists of the model
     */
    public M set(String attr, Object value) {
        if (tableInfoMapping.getTableInfo(getClass()).hasColumnLabel(attr)) {
            attrs.put(attr, value);
            getModifyFlag().add(attr);    // Add modify flag, update() need this flag.
            return (M) this;
        }
        throw new ActiveRecordException("The attribute name is not exists: " + attr);
    }

    /**
     * Put key value pair to the model when the key is not attribute of the model.
     */
    public M put(String key, Object value) {
        attrs.put(key, value);
        return (M) this;
    }

    /**
     * Get attribute of any mysql type
     */
    public <T> T get(String attr) {
        return (T) (attrs.get(attr));
    }

    /**
     * Get attribute of any mysql type. Returns defaultValue if null.
     */
    public <T> T get(String attr, Object defaultValue) {
        Object result = attrs.get(attr);
        return (T) (result != null ? result : defaultValue);
    }

    /**
     * Get attribute of mysql type: varchar, char, enum, set, text, tinytext, mediumtext, longtext
     */
    public String getStr(String attr) {
        return (String) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: int, integer, tinyint(n) n > 1, smallint, mediumint
     */
    public Integer getInt(String attr) {
        return (Integer) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: bigint
     */
    public Long getLong(String attr) {
        return (Long) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: unsigned bigint
     */
    public java.math.BigInteger getBigInteger(String attr) {
        return (java.math.BigInteger) attrs.get(attr);
    }

    // java.util.Data never returned
    // public java.util.Date getDate(String attr) {
    // return attrs.get(attr);
    //}

    /**
     * Get attribute of mysql type: date, year
     */
    public java.sql.Date getDate(String attr) {
        return (java.sql.Date) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: time
     */
    public java.sql.Time getTime(String attr) {
        return (java.sql.Time) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: timestamp, datetime
     */
    public java.sql.Timestamp getTimestamp(String attr) {
        return (java.sql.Timestamp) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: real, double
     */
    public Double getDouble(String attr) {
        return (Double) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: float
     */
    public Float getFloat(String attr) {
        return (Float) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: bit, tinyint(1)
     */
    public Boolean getBoolean(String attr) {
        return (Boolean) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: decimal, numeric
     */
    public java.math.BigDecimal getBigDecimal(String attr) {
        return (java.math.BigDecimal) attrs.get(attr);
    }

    /**
     * Get attribute of mysql type: binary, varbinary, tinyblob, blob, mediumblob, longblob
     */
    public byte[] getBytes(String attr) {
        return (byte[]) attrs.get(attr);
    }

    /**
     * Get attribute of any type that extends from Number
     */
    public Number getNumber(String attr) {
        return (Number) attrs.get(attr);
    }

    /**
     * Paginate.
     *
     * @param pageNumber      the page number
     * @param pageSize        the page size
     * @param select          the select part of the sql statement
     * @param sqlExceptSelect the sql statement excluded select part
     * @param paras           the parameters of sql
     * @return Page
     */
    public Page<M> paginate(int pageNumber, int pageSize, String select, String sqlExceptSelect, Object... paras) {
        if (pageNumber < 1 || pageSize < 1)
            throw new ActiveRecordException("pageNumber and pageSize must be more than 0");

        if (DbKit.dialect.isTakeOverModelPaginate())
            return DbKit.dialect.takeOverModelPaginate(getClass(), pageNumber, pageSize, select, sqlExceptSelect, paras);

        Connection conn = null;
        try {
            conn = DbKit.getConnection();
            long totalRow;
            int totalPage;
            List result = Db.query(conn, "SELECT count(1) " + DbKit.replaceFormatSqlOrderBy(sqlExceptSelect), paras);
            int size = result.size();
            if (size == 1)
                totalRow = ((Number) result.get(0)).longValue();        // totalRow = (Long)result.get(0);
            else if (size > 1)
                totalRow = result.size();
            else
                return new Page<M>(new ArrayList<M>(0), pageNumber, pageSize, 0, 0);    // totalRow = 0;

            totalPage = (int) (totalRow / pageSize);
            if (totalRow % pageSize != 0) {
                totalPage++;
            }

            // --------
            StringBuilder sql = new StringBuilder();
            DbKit.dialect.forPaginate(sql, pageNumber, pageSize, select, sqlExceptSelect);
            List<M> list = find(conn, sql.toString(), paras);
            // 修复如果当前分页没有数据而totalPage 存在的话，则进行重新获取上一页的数据
            if (totalPage > 1 && pageNumber > 1 && (list == null || list.isEmpty())) {
                sql = new StringBuilder();
                pageNumber = totalPage;
                DbKit.dialect.forPaginate(sql, pageNumber, pageSize, select, sqlExceptSelect);
                list = find(conn, sql.toString(), paras);
            }
            return new Page<M>(list, pageNumber, pageSize, totalPage, (int) totalRow);
        } catch (Exception e) {
            throw new ActiveRecordException(e);
        } finally {
            DbKit.close(conn);
        }
    }

    /**
     * @see #paginate(int, int, String, String, Object...)
     */
    public Page<M> paginate(int pageNumber, int pageSize, String select, String sqlExceptSelect) {
        return paginate(pageNumber, pageSize, select, sqlExceptSelect, NULL_PARA_ARRAY);
    }

    /**
     * Return attribute Map.
     * <p/>
     * Danger! The update method will ignore the attribute if you change it directly.
     * You must use set method to change attribute that update method can handle it.
     */
    protected Map<String, Object> getAttrs() {
        return attrs;
    }

    /**
     * Return attribute Set.
     */
    public Set<Entry<String, Object>> getAttrsEntrySet() {
        return attrs.entrySet();
    }

    /**
     * Save model.
     */
    public boolean save() {
        TableInfo tableInfo = tableInfoMapping.getTableInfo(getClass());

        StringBuilder sql = new StringBuilder();
        List<Object> paras = Lists.newArrayList();
        DbKit.dialect.forModelSave(tableInfo, attrs, sql, paras);
        // if (paras.size() == 0)	return false;	// The sql "insert into tableName() values()" works fine, so delete this line

        // --------
        Connection conn = null;
        PreparedStatement pst = null;
        int result;
        try {
            conn = DbKit.getConnection();
            if (DbKit.dialect.isOracle())
                pst = conn.prepareStatement(sql.toString(), new String[]{tableInfo.getPrimaryKey()});
            else
                pst = conn.prepareStatement(sql.toString(), Statement.RETURN_GENERATED_KEYS);

            DbKit.dialect.fillStatement(pst, paras);
            // for (int i=0, size=paras.size(); i<size; i++) {
            // pst.setObject(i + 1, paras.get(i));
            // }

            result = pst.executeUpdate();
            // if (isSupportAutoIncrementKey)
            getGeneratedKey(pst, tableInfo);    // getGeneratedKey(pst, tableInfo.getPrimaryKey());
            getModifyFlag().clear();
            return result >= 1;
        } catch (Exception e) {
            throw new ActiveRecordException(e);
        } finally {
            DbKit.close(pst, conn);
        }
    }

    /**
     * Get id after save method.
     */
    private void getGeneratedKey(PreparedStatement pst, TableInfo tableInfo) throws SQLException {
        String pKey = tableInfo.getPrimaryKey();
        if (get(pKey) == null || DbKit.dialect.isOracle()) {
            ResultSet rs = pst.getGeneratedKeys();
            if (rs.next()) {
                Class colType = tableInfo.getColType(pKey);
                if (colType == Integer.class || colType == int.class)
                    set(pKey, rs.getInt(1));
                else if (colType == Long.class || colType == long.class)
                    set(pKey, rs.getLong(1));
                else
                    set(pKey, rs.getObject(1));        // It returns Long object for int colType
                rs.close();
            }
        }
    }

    /**
     * Delete model.
     */
    public boolean delete() {
        TableInfo tInfo = tableInfoMapping.getTableInfo(getClass());
        String pKey = tInfo.getPrimaryKey();
        Object id = attrs.get(pKey);
        if (id == null)
            throw new ActiveRecordException("You can't delete model without id.");
        return deleteById(tInfo, id);
    }

    /**
     * Delete model by id.
     *
     * @param id the id value of the model
     * @return true if delete succeed otherwise false
     */
    public boolean deleteById(Object id) {
        if (id == null)
            throw new IllegalArgumentException("id can not be null");
        TableInfo tInfo = tableInfoMapping.getTableInfo(getClass());
        return deleteById(tInfo, id);
    }

    private boolean deleteById(TableInfo tInfo, Object id) {
        String sql = DbKit.dialect.forModelDeleteById(tInfo);
        return Db.update(sql, id) >= 1;
    }

    /**
     * Update model.
     */
    public boolean update() {
        if (getModifyFlag().isEmpty())
            return false;

        TableInfo tableInfo = tableInfoMapping.getTableInfo(getClass());
        String pKey = tableInfo.getPrimaryKey();
        Object id = attrs.get(pKey);
        if (id == null)
            throw new ActiveRecordException("You can't update model without Primary Key.");

        StringBuilder sql = new StringBuilder();
        List<Object> paras = new ArrayList<Object>();
        DbKit.dialect.forModelUpdate(tableInfo, attrs, getModifyFlag(), pKey, id, sql, paras);

        if (paras.size() <= 1) {    // Needn't update
            return false;
        }

        // --------
        Connection conn = null;
        try {
            conn = DbKit.getConnection();
            int result = Db.update(conn, sql.toString(), paras.toArray());
            if (result >= 1) {
                getModifyFlag().clear();
                return true;
            }
            return false;
        } catch (Exception e) {
            throw new ActiveRecordException(e);
        } finally {
            DbKit.close(conn);
        }
    }

    /**
     * Find model.
     */
    private List<M> find(Connection conn, String sql, Object... paras) throws Exception {
        Class<? extends Model> modelClass = getClass();
        if (DbKit.devMode)
            checkTableName(modelClass, sql);

        PreparedStatement pst = conn.prepareStatement(sql);
        DbKit.dialect.fillStatement(pst, paras);
        // for (int i=0; i<paras.length; i++) {
        // pst.setObject(i + 1, paras[i]);
        // }

        ResultSet rs = pst.executeQuery();
        List<M> result = ModelBuilder.build(rs, modelClass);
        DbKit.closeQuietly(rs, pst);

        return result;
    }

    /**
     * Find model.
     *
     * @param sql   an SQL statement that may contain one or more '?' IN parameter placeholders
     * @param paras the parameters of sql
     * @return the list of Model
     */
    public List<M> find(String sql, Object... paras) {
        Connection conn = null;
        try {
            conn = DbKit.getConnection();
            return find(conn, sql, paras);
        } catch (Exception e) {
            throw new ActiveRecordException(e);
        } finally {
            DbKit.close(conn);
        }
    }

    /**
     * Check the table name. The table name must in sql.
     */
    private void checkTableName(Class<? extends Model> modelClass, String sql) {
        TableInfo tableInfo = tableInfoMapping.getTableInfo(modelClass);
        if (!sql.toLowerCase().contains(tableInfo.getTableName().toLowerCase()))
            throw new ActiveRecordException("The table name: " + tableInfo.getTableName() + " not in your sql.");
    }

    /**
     * @see #find(String, Object...)
     */
    public List<M> find(String sql) {
        return find(sql, NULL_PARA_ARRAY);
    }

    /**
     * Find first model. I recommend add "limit 1" in your sql.
     *
     * @param sql   an SQL statement that may contain one or more '?' IN parameter placeholders
     * @param paras the parameters of sql
     * @return Model
     */
    public M findFirst(String sql, Object... paras) {
        List<M> result = find(sql, paras);
        return result.size() > 0 ? result.get(0) : null;
    }

    /**
     * @param sql an SQL statement
     * @see #findFirst(String, Object...)
     */
    public M findFirst(String sql) {
        List<M> result = find(sql, NULL_PARA_ARRAY);
        return result.size() > 0 ? result.get(0) : null;
    }

    /**
     * Find model by id.
     *
     * @param id the id value of the model
     */
    public M findById(Object id) {
        return findById(id, StringPool.ASTERISK);
    }

    /**
     * Find model by id. Fetch the specific columns only.
     * Example: User user = User.dao.findById(15, "name, age");
     *
     * @param id      the id value of the model
     * @param columns the specific columns separate with comma character ==> ","
     */
    public M findById(Object id, String columns) {
        final TableInfo tInfo = tableInfoMapping.getTableInfo(getClass());
        final String sql = DbKit.dialect.forModelFindById(tInfo, columns);
        final List<M> result = find(sql, id);
        return result.size() > 0 ? result.get(0) : null;
    }

    /**
     * Set attributes with other model.
     *
     * @param model the Model
     * @return this Model
     */
    public M setAttrs(M model) {
        return setAttrs(model.getAttrs());
    }

    /**
     * Set attributes with Map.
     *
     * @param attrs attributes of this model
     * @return this Model
     */
    public M setAttrs(Map<String, Object> attrs) {
        for (Entry<String, Object> e : attrs.entrySet()) {
            set(e.getKey(), e.getValue());
        }
        return (M) this;
    }

    /**
     * Remove attribute of this model.
     *
     * @param attr the attribute name of the model
     * @return this model
     */
    public M remove(String attr) {
        attrs.remove(attr);
        getModifyFlag().remove(attr);
        return (M) this;
    }

    /**
     * Remove attributes of this model.
     *
     * @param attrs the attribute names of the model
     * @return this model
     */
    public M remove(String... attrs) {
        if (attrs != null)
            for (String a : attrs) {
                this.attrs.remove(a);
                this.getModifyFlag().remove(a);
            }
        return (M) this;
    }

    /**
     * Remove attributes if it is null.
     *
     * @return this model
     */
    public M removeNullValueAttrs() {
        for (Iterator<Entry<String, Object>> it = attrs.entrySet().iterator(); it.hasNext(); ) {
            Entry<String, Object> e = it.next();
            if (e.getValue() == null) {
                it.remove();
                getModifyFlag().remove(e.getKey());
            }
        }
        return (M) this;
    }

    /**
     * Keep attributes of this model and remove other attributes.
     *
     * @param attrs the attribute names of the model
     * @return this model
     */
    public M keep(String... attrs) {
        if (attrs != null && attrs.length > 0) {
            Map<String, Object> newAttrs = new HashMap<String, Object>(attrs.length);
            Set<String> newModifyFlag = new HashSet<String>();
            for (String a : attrs) {
                if (this.attrs.containsKey(a))    // prevent put null value to the newColumns
                    newAttrs.put(a, this.attrs.get(a));
                if (this.getModifyFlag().contains(a))
                    newModifyFlag.add(a);
            }
            this.attrs = newAttrs;
            this.modifyFlag = newModifyFlag;
        } else {
            this.attrs.clear();
            this.getModifyFlag().clear();
        }
        return (M) this;
    }

    /**
     * Keep attribute of this model and remove other attributes.
     *
     * @param attr the attribute name of the model
     * @return this model
     */
    public M keep(String attr) {
        if (attrs.containsKey(attr)) {    // prevent put null value to the newColumns
            Object keepIt = attrs.get(attr);
            boolean keepFlag = getModifyFlag().contains(attr);
            attrs.clear();
            getModifyFlag().clear();
            attrs.put(attr, keepIt);
            if (keepFlag)
                getModifyFlag().add(attr);
        } else {
            attrs.clear();
            getModifyFlag().clear();
        }
        return (M) this;
    }

    /**
     * Remove all attributes of this model.
     *
     * @return this model
     */
    public M clear() {
        attrs.clear();
        getModifyFlag().clear();
        return (M) this;
    }

    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(super.toString()).append(" {");
        boolean first = true;
        for (Entry<String, Object> e : attrs.entrySet()) {
            if (first)
                first = false;
            else
                sb.append(", ");

            Object value = e.getValue();
            if (value != null)
                value = value.toString();
            sb.append(e.getKey()).append(StringPool.COLON).append(value);
        }
        sb.append(StringPool.RIGHT_BRACE);
        return sb.toString();
    }

    public boolean equals(Object o) {
        return o instanceof Model && (o == this || this.attrs.equals(((Model) o).attrs));
    }

    public int hashCode() {
        return (attrs == null ? 0 : attrs.hashCode()) ^ (getModifyFlag() == null ? 0 : getModifyFlag().hashCode());
    }

    /**
     * Find model by cache.
     *
     * @param cacheName the cache name
     * @param key       the key used to get date from cache
     * @return the list of Model
     * @see #find(String, Object...)
     */
    public List<M> findByCache(String cacheName, Object key, String sql, Object... paras) {
        ICache cache = DbKit.getCache();
        List<M> result = cache.get(cacheName, key);
        if (result == null) {
            result = find(sql, paras);
            cache.put(cacheName, key, result);
        }
        return result;
    }

    /**
     * @see #findByCache(String, Object, String, Object...)
     */
    public List<M> findByCache(String cacheName, Object key, String sql) {
        return findByCache(cacheName, key, sql, NULL_PARA_ARRAY);
    }

    /**
     * Paginate by cache.
     *
     * @param cacheName the cache name
     * @param key       the key used to get date from cache
     * @return Page
     * @see #paginate(int, int, String, String, Object...)
     */
    public Page<M> paginateByCache(String cacheName, Object key, int pageNumber, int pageSize, String select, String sqlExceptSelect, Object... paras) {
        ICache cache = DbKit.getCache();
        Page<M> result = cache.get(cacheName, key);
        if (result == null) {
            result = paginate(pageNumber, pageSize, select, sqlExceptSelect, paras);
            cache.put(cacheName, key, result);
        }
        return result;
    }

    /**
     * @see #paginateByCache(String, Object, int, int, String, String, Object...)
     */
    public Page<M> paginateByCache(String cacheName, Object key, int pageNumber, int pageSize, String select, String sqlExceptSelect) {
        return paginateByCache(cacheName, key, pageNumber, pageSize, select, sqlExceptSelect, NULL_PARA_ARRAY);
    }

    /**
     * Return attribute names of this model.
     */
    public String[] getAttrNames() {
        Set<String> attrNameSet = attrs.keySet();
        return attrNameSet.toArray(new String[attrNameSet.size()]);
    }

    /**
     * Return attribute values of this model.
     */
    public Object[] getAttrValues() {
        java.util.Collection<Object> attrValueCollection = attrs.values();
        return attrValueCollection.toArray(new Object[attrValueCollection.size()]);
    }

    /**
     * Return json string of this model.
     */
    public String toJson() {
        return com.jfinal.kit.JsonKit.toJson(attrs, 4);
    }
}